# python-resmon - Python tools for Resmon monitoring and the Circonus API
# Copyright (c) 2010 Crown coypright
# 
# This file is part of python-resmon.
# 
# python-resmon is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# 
# python-resmon is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with python-resmon.  If not, see <http://www.gnu.org/licenses/>.

"""Sync checks exposed by our Resmon Aggregator to Circonus.
"""

import sys
import time
import yaml
import urlparse
from lxml import html, etree
import fnmatch

import circonus.api as circonus

from ruleparse.loader import load

EXTERNAL_URL = 'http://88.211.33.26:81/'
AGENT_ID = 3


DEFAULT_RULES = [
    {'severity': 1, 'criteria': 'on absence', 'value': 1},
]


class Job(object):
    def __init__(self, queue, delay=90):
        self.queue = queue
        self.time = time.time() + delay
        self.queue.add(self)

    def __cmp__(self, ano):
        return cmp(self.time, ano.time)


class SyncRulesJob(Job):
    def __init__(self, queue, check_name, metric, rules, delay=90):
        self.check_name = check_name
        self.metric = metric
        self.rules = rules
        super(SyncRulesJob, self).__init__(queue, delay)

    def run(self):
        naive_sync_rules(self.check_name, self.metric, self.rules)


class PurgeRulesJob(Job):
    def __init__(self, queue, check_name, metric, delay=90): 
        self.check_name = check_name
        self.metric = metric
        super(PurgeRulesJob, self).__init__(queue, delay)

    def run(self):
        old_rules = self.metric.list_rules()
        if old_rules:
            print "Purging rules under", check_name, "for metric", metric.name
            for rule in old_rules:
                rule.delete()


class SetContactGroupJob(Job):
    def __init__(self, queue, metric, contact_group, severity=1, delay=120):
        self.metric = metric
        self.contact_group = contact_group
        self.severity = severity
        super(SetContactGroupJob, self).__init__(queue, delay)

    def run(self):
        print "Adding contact group", self.contact_group, "to", self.metric
        try:
            self.metric.add_contact_group(self.contact_group, severity=self.severity)
        except circonus.APIError, e:
            # don't care, assuming the error is this known string
            if e.message() != 'that contact group has already been added to this rule and severity':
                raise

import heapq

class JobQueue(object):
    def __init__(self, jobs=[]):
        self.queue = list(jobs)
        heapq.heapify(self.queue)

    def add(self, job):
        heapq.heappush(self.queue, job)

    def size(self):
        return len(self.queue)

    def wait_time(self):
        try:
            t = self.queue[0].time
        except (IndexError, AttributeError):
            return 0
        else:
            return max(0, t - time.time())

    def run(self):
        while True:
            if not self.queue:
                break
            wait = self.wait_time()
            if wait:
                print "Waiting %d seconds to start deferred sync jobs..." % jobqueue.wait_time()
                time.sleep(wait)

            job = heapq.heappop(self.queue)
            try:
                job.run()
            except circonus.APIError, e:
                attempts = getattr(job, 'attempts', 0)
                job.attempts = attempts + 1
                if job.attempts < 3:
                    print "Caught", e, "- reenqueuing job"
                    job.time += 60
                    self.add(job)
                else:
                    print "Retried", job, job.attempts, "times. Aborting."


jobqueue = JobQueue()


def absence_rule(host, metric):
    sev = metric.get_absence_severity()
    return {'severity': sev, 'criteria': 'on absence', 'value': 1}


def default_url_for_node(node):
    """Construct a URL for Circonus to query a node"""
    return 'http://%s:81/' % node.hostname


def aggregator_url_for_node(node):
    """Construct a URL for Circonus to query an aggregator for a node"""
    return urlparse.urljoin(EXTERNAL_URL, node.hostname)


def load_model(fname='rules.cm', url_for_node=default_url_for_node):
    checks = {}
    for node in load(fname):
        host = node.hostname
        metrics = node.get_metrics()

        external_url = url_for_node(node)
        urlparts = urlparse.urlparse(external_url)
        uid = 'resmon:%s' % external_url
        name = '%s (%s)' % (host, uid)

        params = {
            'host': host,
            'name': name,
            'agent_id': AGENT_ID,
            'module': 'resmon',
            'metric_name': [m.name for m in metrics],
            'period_seconds': 60,
            'port': urlparts.port or '81',
            'target': urlparts.hostname,
            'timeout_seconds': 10,
            'display_name_' + urlparts.hostname: name,
            'url': external_url,
        }

        ruleset = {}

        for m in metrics:
            rules = [absence_rule(node, m)] + [a.circonus_rule() for a in m.alerts]
            ruleset[m.name] = rules
        params['ruleset'] = ruleset
        checks[uid] = params
    return checks


class options:
    no_act = False


def generate_model(checks_file):
    """Generate a model of the checks that we would like to ensure are in Resmon.
    
    This connects to the aggregator to download the list of hosts that are exposed
    and the results that are being exposed for each host.
    """
    rule_map = yaml.load(open(checks_file))

    checks = {}
    for host, url in load_bundles(BASE_URL):
        absurl = urlparse.urljoin(BASE_URL, url)
        metrics = {}
        for module, service, metriclist in load_checks(absurl):
            if module in rule_map:
                rules = rule_map[module]
                for metric, check in rules.items():
                    if '*' in metric or '?' in metric:
                        matching = fnmatch.filter(metriclist, metric)
                        for m in matching:
                            metric_name = '%s`%s`%s' % (module, service, m)
                            metrics.setdefault(metric_name, []).extend(check)
                    elif metric in metriclist:
                        metric_name = '%s`%s`%s' % (module, service, metric)
                        metrics.setdefault(metric_name, []).extend(check)

        if metrics:
            external_url = urlparse.urljoin(EXTERNAL_URL, url)
            urlparts = urlparse.urlparse(external_url)
            uid = 'resmon:%s' % external_url
            name = '%s (%s)' % (host, uid)

            params = {
                'host': host,
                'name': name,
                'agent_id': AGENT_ID,
                'module': 'resmon',
                'metric_name': list(metrics),
                'period_seconds': 60,
                'port': urlparts.port or '81',
                'target': urlparts.hostname,
                'timeout_seconds': 10,
                'display_name_' + urlparts.hostname: name,
                'url': external_url,
            }

            ruleset = {}

            for metric, rules in metrics.items():
                ruleset[metric] = DEFAULT_RULES + rules
            params['ruleset'] = ruleset
            checks[uid] = params
    return checks
    

def sync_model_to_circonus(circonus, model, hosts=None, no_act=True):
    contact_group = circonus.list_contact_groups()[0]
    for uid, params in model.items():
        # unpack model
        check = params.copy()
        host = check.pop('host')
        name = check.pop('name')
        ruleset = check.pop('ruleset')

        if hosts is not None:
            if host not in hosts:
                continue
        sync_check_to_circonus(circonus, uid, name, check, ruleset, contact_group, no_act=no_act)
    jobqueue.run()


def sync_check_to_circonus(api, uid, name, check_params, ruleset={}, contact_group=None, no_act=True):
    suffix = '(%s)' % uid
    existing = api.get_checks_by_name_match(suffix)
    
    # Flag to control console output
    updated = False

    # sync the check
    metrics = check_params['metric_name']
    if not existing:
        print "Create check", name
        for mname in set(metrics):
            print "  Add", mname

        if not no_act:
            check = api.create_bundle(**check_params)
        updated = True
    else:
        # remove duplicates
        if len(existing) > 1:
            for check in existing[1:]:
                check.set_enabled(False)

        check = existing[0] # first match

        existing_metric_objects = [m for m in check.list_metrics() if m.enabled]
        existing_metrics = set([m.name for m in existing_metric_objects])

        # Only do the update if the metrics list doesn't match
        #
        # We currently ignore the situation where other parameters differ
        # as the metrics to monitor is the only dynamic factor at present
        if set(metrics) != existing_metrics:
            print "Update check", check.name
            enable = set(metrics) - existing_metrics
            disable = existing_metrics - set(metrics)
            for mname in enable:
                print "  Add/Enable", mname

            # delete rules from metric before deleting
            for m in existing_metric_objects:
                if m.name in disable:
                    print "  Disable", m.name
                    for rule in m.list_rules():
                        rule.delete()
                    

            kwargs = check_params.copy()
            if 'name' in kwargs:
                del(kwargs['name'])
            del(kwargs['agent_id'])
            del(kwargs['module'])
            del(kwargs['target'])
            del(kwargs['port'])

            if not no_act:
                check.update(**kwargs)
            updated = True

    # Sync the rules contained within the check
    if not no_act:
        for metric in check.list_metrics():
            if metric.name not in metrics and metric.enabled:
                print "Unwanted metric", metric.name, "still visible"
                continue
            rules = ruleset.get(metric.name, [])

            if updated:
                # wait 90s for Circonus to get itself in order to receive the rules
                delay = 90
            else:
                delay = 0

            old_rules = metric.list_rules()
            if rules_updated(old_rules, rules):
                if no_act:
                    #FIXME: print what we would do
                    print "Sync rules"
                else:
                    SyncRulesJob(jobqueue, check.name, metric, rules, delay=delay)

            if any([r['severity'] == 1 for r in rules]):
                SetContactGroupJob(jobqueue, metric, contact_group, severity=1, delay=delay)


def rules_updated(circonus_rules, ruleset):
    """Return True if a set of rules retrieved from circonus
    do not match the ruleset given.
    """
    if len(circonus_rules) != len(ruleset):
#        print "lengths differ"
#        print circonus_rules, ruleset
        return True

    cjson = [c.json for c in circonus_rules]
    for i, rule in enumerate(ruleset):
        crule = circonus_rules[i]
#        print crule.json, rule
        if rule['severity'] != crule.severity:
#            print "severities differ", crule.severity, '!=', rule['severity']
#            print cjson, ruleset
            return True

        if rule['criteria'].lower() != crule.criteria.lower():
#            print "criteria differ", crule.criteria, '!=', rule['criteria']
#            print cjson, ruleset
            return True

        if (crule.value == True and rule['value'] != 1) or (crule.value != True and str(rule['value']) != str(crule.value)):
            if not isinstance(rule['value'], float) or '%f' % rule['value'] != crule.value:
#                print "values differ", crule.value, '!=', rule['value']
#                print cjson, ruleset
                return True

#        print crule.metric_name, "match", rule['criteria'], 'at', 'S%d' % rule['severity']

    return False


def naive_sync_rules(check_name, metric, rules, updated=False, no_act=False):
    """Sync a list of rules to a metric.

    Circonus doesn't provide primary keys with which to update or delete
    rules. Instead it indexes rules by "order", and order changes as rules are
    added and deleted.

    To work around this, this basic implementation deletes all rules and
    re-adds them. While this is slow, it is simple and guaranteed to work.
    
    """
    old_rules = metric.list_rules()
    if not rules_updated(old_rules, rules):
        # Nothing to do
        return False
    elif not updated:
        print "Updating rules under", check_name, "for metric", metric.name

    # clear existing rules
    if not no_act:
        if old_rules:
            print "  Deleting", len(old_rules), "existing rules"
            for rule in old_rules:
                rule.delete()

    for r in rules:
        criteria = r['criteria']
        value = r['value']
        severity = r['severity']
        print "  Add rule", metric.name, criteria, value, "Severity", severity
        if not no_act:
            metric.append_rule(criteria, value, severity)


def sync_rules_to_circonus(circonus, metric, rules, no_act=True):
        """Sync a list of rules to a metric.

        This attempts to compare the rules that are already in place with the
        rules that we wish a metric to have. However it does not respect order
        so the rules may appear in an arbitrary order.

        """
        # Pass 1: delete rules that are unwanted
        existing_rules = {}
        for r in metric.list_rules():
            k = (r.criteria, str(r.value), str(r.severity))
            existing_rules.setdefault(k, []).append(r)
        for r in rules:
            k = (r['criteria'], str(r['value']), str(r['severity']))
            if k in existing_rules:
                existing_rules[k].pop()
                if not existing_rules[k]:
                    del(existing_rules[k])
        for ruleset in existing_rules.values():
            for rule in ruleset:
                if not updated:
                    print "Update rules", check.name
                    updated = True
                print "  Delete rule", metric.name, rule.criteria, rule.value, rule.severity
                if not no_act:
                    rule.delete()

        # re-scan rules as now that we have deleted some, the orders may have changed
        existing_rules = {}
        for r in metric.list_rules():
            k = (r.criteria, str(r.value), str(r.severity))
            existing_rules.setdefault(k, []).append(r)
        for r in rules:
            k = (r['criteria'], str(r['value']), str(r['severity']))
            if k not in existing_rules:
                if not updated:
                    print "Update rules", check.name
                    updated = True
                print "  Add rule", metric.name, r['criteria'], r['value'], r['severity']
                if not no_act:
                    metric.create_rule(**r)

